iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Vibe Coding

夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略系列 第 23

Day 23|地圖看懂了,角色真的會照著走嗎?從 BuJo Code 看懂 State Machine 怎麼實作

  • 分享至 

  • xImage
  •  

昨天,我們已經學會怎麼看一張 State Diagram(狀態圖)了。

今天就直接把它放回 BuJo,一起來看看圖上的 State 和 Transition,是怎麼一路走進 Code 裡的吧!


先把 BuJo 的 Activity State Machine 攤開來看

https://ithelp.ithome.com.tw/upload/images/20260910/20183484XpSZLPUPEH.png

BuJo 的 Activity 主要會在四個 State 之間移動:

recruiting → 揪團中
voting    → 建立者決選中
confirmed → 已成團
cancelled → 已取消

活動建立後會先進入 recruiting,接著依照截止時間、報名結果和建立者的操作,走向不同 State。

例如報名截止,而且符合繼續進入決選的條件:

recruiting
    ↓
  voting

之後建立者確認成團,就會再走向:

voting
  ↓
confirmed

但如果報名截止後不符合繼續條件,也可能直接走向 cancelled;在符合規則的情況下,建立者也可以從 recruiting 直接確認成團。

昨天看這張圖時,我們主要在理解:

「現在有哪些 State?它們之間可以怎麼移動?」

但真的要把這些箭頭寫進程式,還得再把每一條 Transition 的規則拆得更清楚。

這時候就會用到 Transition Table(狀態轉換表)


Transition Table:把圖上的箭頭翻成規則

State Diagram 很適合先看整個流程,但真的準備寫 Code 時,一條:

recruiting → voting

還是太簡略了。

因為程式除了要知道「可以從哪裡走到哪裡」,還需要知道:

什麼事情發生、哪些條件成立時,這條 Transition 才可以走。

這時候就可以把 State Diagram 整理成 Transition Table(狀態轉換表)

Current State Trigger(觸發) Guard / Condition(條件) Next State
recruiting 報名截止 人數已達標,可以進入決選 voting
recruiting 報名截止 人數未達標 cancelled
recruiting 建立者提前確認成團 確認時段未過期 confirmed
voting 建立者確認成團 確認時段未過期 confirmed
voting 決策截止 仍未確認成團 cancelled
recruiting / voting 建立者手動取消 目前仍是可取消的進行中 State cancelled

像圖上的:

recruiting → voting

現在就可以讀成:

Current State:recruiting
Trigger:報名截止
Guard / Condition:已達標
Next State:voting

所以:

State Diagram → 負責先讓我們看懂整體流程
Transition Table → 則把每一條箭頭的規則攤開。

有些比較細的 Guard 不一定會全部塞進圖裡,例如確認成團時還要檢查時段是否已經過期,就可以留到 Table 或 Code 再補完整。


State 也會決定「現在能不能做」

State Machine 不只規定下一個 State 可以走去哪裡,也會限制目前可以做哪些事。

例如 BuJo 只有在 recruiting(揪團中)時可以報名。

一旦活動進入 votingconfirmedcancelled,Backend 就會擋下新的報名。

回到 joinActivity(),先看一般新報名的這個判斷:

// 一般新報名:目前不是 recruiting,就直接拒絕
if (!isResubmission && activity.status !== "recruiting") {
  return {
    status: 400,
    message: req.t("activity.notRecruiting"),
  };
}

所以 BuJo 在這裡守住的 State Rule 很單純:

recruiting
→ 可以報名

voting / confirmed / cancelled
→ 不能報名

也就是說,State 不只是拿來顯示活動目前走到哪裡,也能成為 Backend 判斷一個 Action 是否合法的依據。


recruiting → voting 一路追進 Code

接下來就挑 State Diagram 裡的一條 Transition,看看它真正寫進程式後會變成什麼樣子。

https://ithelp.ithome.com.tw/upload/images/20260910/20183484W4wI27YT1o.png

圖上的規則是:

recruiting
↓ 報名截止(達標)
voting

如果把這條箭頭拆成程式需要知道的資訊,就是:

Current State:recruiting
Trigger:到達 vote_deadline_at
Guard / Condition:符合繼續進入決選的條件
Next State:voting

其中 vote_deadline_at,就是 BuJo 現在用來表示報名截止時間的欄位。

Rule 最後怎麼進 Code?

回到 getActivity(),這幾段 Code 剛好可以一路對上剛才的規則。

以下節錄和這次 Transition 有關的部分:

// 1. 先取得目前 State 與報名截止時間
let currentStatus = activity.status;
const recruitingDeadline = sched?.vote_deadline_at;

// 2. 只有 recruiting,而且報名截止時間已到,才開始處理 Transition
if (
  currentStatus === "recruiting" &&
  sched &&
  now >= recruitingDeadline
) {
  const target = activity.participant_target;
  let nextStatus;

  // 3. 有設定目標人數,但人數未達標 → cancelled
  if (target && joinedCount < target) {
    nextStatus = "cancelled";

  // 4. Range Mode 沒有人提交可用時間 → cancelled
  } else if (
    isRangeMode &&
    !target &&
    (activity.availabilityRanges ?? []).filter(
      (r) => r.user_id !== activity.creator_id
    ).length === 0
  ) {
    nextStatus = "cancelled";

  // 5. 沒有落入取消條件 → voting
  } else {
    nextStatus = "voting";
  }

  // 6. 把新的 State 寫回 Activity
  await prisma.activity.updateMany({
    where: {
      id: activity.id,
      status: "recruiting",
    },
    data: {
      status: nextStatus,
    },
  });
}

這樣重新看,就會發現 State Diagram 裡的元素其實都找得到對應位置:

Current State
→ currentStatus === "recruiting"

Trigger
→ now >= recruitingDeadline

Guard / Condition
→ 是否落入取消條件

Next State
→ nextStatus = "voting" / "cancelled"

原本圖上只有一條:

recruiting → voting

到了 Code 裡,就變成一組真正會被程式執行的條件。


最後再用 Test 確認這條路有沒有走對

Code 寫好之後,還需要確認:

符合這些條件時,Activity 真的會從 recruiting 走到 voting 嗎?

這時候就回到 Day15 看過的 Test。

BuJo 的 State Machine Test 裡,就有直接驗證這件事。這裡先只看最後的 Assert:

// 預期 Activity 從 recruiting 更新成 voting
expect(prisma.activity.updateMany).toHaveBeenCalledWith({
  where: {
    id: ACTIVITY_ID,
    status: "recruiting",
  },
  data: {
    status: "voting",
  },
});

這個 Assert 驗證的,就是圖上那條 recruiting → voting 有沒有真的發生。

所以我們前面一路走過來的:

State Diagram
↓
Rule
↓
Code
↓
Test

到了這裡才真正串起來。

圖先定義「應該怎麼走」,Code 把規則實作出來,Test 再確認這條 Transition 真的沒有走歪。


Transition 也不一定只改一個 status

前面的 recruiting → voting,最明顯的變化就是 Activity 的 status

但有些 Transition 發生時,除了 State 改變,和這個 State 有關的資料也要一起更新。

BuJo 的「確認成團」就是一個例子。

當建立者確認成團時,程式會先檢查目前是不是允許確認的 State;確認成功後,再把 Activity 改成 confirmed,同時記住最後確認的是哪一個時段。

以下只節錄這次要看的部分:

// 1. 只有 recruiting 或 voting 可以確認成團
if (
  activity.status !== "recruiting" &&
  activity.status !== "voting"
) {
  return res.status(400).json({
    message: req.t("activity.formationNotAllowedInState"),
  });
}

// ...

// 2. 確認成功後,Activity 進入 confirmed
await tx.activity.updateMany({
  where: {
    id,
    status: activity.status,
  },
  data: {
    status: "confirmed",
  },
});

// 3. 同時記住最後確認的時段
await tx.activitySchedule.update({
  where: {
    activity_id: id,
  },
  data: {
    confirmed_slot_id: confirmedSlotId,
  },
});

把這幾段放在一起看就很清楚了。

目前只有:

recruiting
或
voting

可以進行確認成團。

成功之後,一方面:

Activity.status
→ confirmed

另一方面:

ActivitySchedule.confirmed_slot_id
→ 最後確認的時段

所以一次 State Transition,不一定只是:

status A → status B

它代表的是系統進入了一個新的狀態

如果這個新狀態還需要留下其他 Domain Data(領域資料),那些資料也可能要在同一個流程裡一起更新。


這次真的有被 State Machine 救到

那狀態機的分享就到這裡啦!

我真心覺得跟 State Machine 是相見恨晚啊~~~

它完美拯救了當初被各種邊界條件搞得很混亂的我。

而且這些條件判斷,也不是簡單丟一兩句話給 AI,就能保證它幫你全部想清楚的。

所以對我來說,State Machine 真的是一個很好用的視覺化工具。

它可以把原本散在 Code 和腦袋裡的流程整理出來,不管是自己寫程式,還是跟夥伴討論功能,都比較不會再搞不清楚「我們現在到底走到哪裡啦~~~」

不過今天我們都先假設:

一次只有一個 Request 在改 State。

那如果兩個 Request 幾乎同時進來,都想把同一個 recruiting 改成下一個 State,會發生什麼事?

明天就從今天看過的 updateMany + where status 繼續往下拆,來認識 Optimistic Lock(樂觀鎖)


上一篇
Day 22|流程一多腦袋就打結?從 State Machine 看懂系統的狀態怎麼走
下一篇
Day24|先做再說,真的撞到再處理?從樂觀鎖看懂並發寫入
系列文
夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言